Skip to content

fix(ssrf): honour saasMode for RFC-1918 private IPs in isSafeURL (A2A unblock) - #1785

Closed
HongmingWang-Rabbit wants to merge 1 commit into
stagingfrom
fix/ssrf-saas-mode-for-private-ips
Closed

HongmingWang-Rabbit wants to merge 1 commit into
stagingfrom
fix/ssrf-saas-mode-for-private-ips

Conversation

@HongmingWang-Rabbit

Copy link
Copy Markdown
Contributor

[molecule-platform-evolvement-manager-agent]

TL;DR

`isSafeURL` in `workspace-server/internal/handlers/ssrf.go` hardcoded RFC-1918 rejection, never called `saasMode()`. This returns 502 on every A2A call when platform + workspaces share a Docker bridge network (local dev) OR share a VPC (SaaS). Backport of commit 62a2118 from `feat/credential-helper-auto-refresh` as an isolated, reviewable PR to staging.

Symptom observed locally today (2026-04-23)

After `docker compose down + up` on my dev machine, every scheduler dispatch failed:

```
ProxyA2A: unsafe URL for workspace 169dc548-f8b5-...: forbidden private/metadata IP: 172.18.0.42
Scheduler: 'Pick up work (every 15 min)' error: workspace URL is not publicly routable
```

30-minute window: 61 cron_run errors / 72 events, 0 successful A2A dispatches, 0 real response bodies. Team looked idle — they were actively rejected at the proxy layer.

Root cause

`ssrf.go` had a sibling helper `validateAgentURL` in `registry.go` that correctly honoured `saasMode()` for registration. But `isSafeURL` (used by `a2a_proxy.go:393`) didn't. The saasMode()-aware test assertion `TestIsPrivateOrMetadataIP_SaaSMode` existed; the production implementation never called saasMode().

Fix

Wire `saasMode()` into `isSafeURL`:

  • `saasMode()==true`: allow RFC-1918 (10/8, 172.16/12, 192.168/16) + IPv6 ULA fd00::/8
  • `saasMode()==false`: keep RFC-1918 blocked (single-container self-hosted default)
  • Always block: 169.254/16 metadata, TEST-NET, 100.64/10 CGNAT, loopback, link-local

Also tightens IPv6: link-local multicast, interface-local multicast, and DNS-resolved v6 addrs are now checked.

Deploy + verify

Tenant/local dev needs `MOLECULE_DEPLOY_MODE=saas` set on the platform container (already set for SaaS prod via `MOLECULE_ORG_ID` fallback). For local dev I added `docker-compose.override.yml` with the env var.

Post-fix on my machine:

  • 0 "workspace URL is not publicly routable" errors in 60s sample
  • Direct A2A `curl -X POST .../a2a` returns agent response JSON
  • Scheduler cron_run errors went 61 → 0

Why this wasn't caught earlier

Commit 62a2118 landed on `feat/credential-helper-auto-refresh` (Apr 22) but the branch carries other unrelated changes (credential helper scripts, Dockerfile tweaks) so it hasn't merged to staging. Every agent fleet cycle on stale staging/main has been hitting this silently — the 502s don't fail CI (they're runtime errors on a path no unit test exercises end-to-end against real Docker networking).

Test coverage

Existing `ssrf_test.go` (`TestIsPrivateOrMetadataIP_SaaSMode`) already asserted the behavior this fix implements. The test was passing in isolation because it tested `isPrivateOrMetadataIP` directly, not `isSafeURL`. Consider adding a wrapper test: `TestIsSafeURL_SaaSMode_AllowsRFC1918`. I'll file a follow-up issue to tighten that.

Related follow-ups I'll file after this lands

See companion audit comment below — several other SSRF/validation callsites have the same pattern class.

🤖 Generated with Claude Code

…at branch)

Workspaces in SaaS and local-docker deployments register with RFC-1918
private IPs (172.31.x.x on AWS default VPCs; 172.18.x.x on Docker bridge
networks). The SSRF guard in ssrf.go blocked them unconditionally as
"forbidden private/metadata IP", returning 502 on every /workspaces/:id/a2a
call — chat, delegation fanout, webhooks all failed.

saasMode()-aware test assertions existed (TestIsPrivateOrMetadataIP_SaaSMode)
but the implementation never called saasMode(). Wire it up. In SaaS:
  - RFC-1918 (10/8, 172.16/12, 192.168/16) and IPv6 ULA fd00::/8 are allowed
  - 169.254/16 metadata, TEST-NET, 100.64/10 CGNAT, loopback, link-local
    stay blocked in every mode

Also hardens IPv6: link-local multicast and interface-local multicast
are now rejected; DNS-resolved v6 addrs are checked too.

Symptom observed locally (docker compose up after rebuild):
  ProxyA2A: unsafe URL for workspace a8af9d79-...: forbidden private/metadata
  IP: 172.18.0.37
  → scheduler dispatched 61 cron_run errors in 30 min, 0 successful A2A calls

Repro:
  - docker compose up -d (platform + workspaces on default bridge)
  - curl -X POST localhost:8080/workspaces/:id/a2a  → 502 "workspace URL is
    not publicly routable"
  - Activity logs: cron_run status=error, error_detail="workspace URL is
    not publicly routable"

This backports commit 62a2118 from feat/credential-helper-auto-refresh
(which carries other unrelated changes) so the SSRF unblock lands on
staging as a focused, reviewable fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@HongmingWang-Rabbit

Copy link
Copy Markdown
Contributor Author

[molecule-platform-evolvement-manager-agent]

Companion audit — similar-class issues

Did a sweep of every outbound-URL site in `workspace-server` looking for the same "validator blocks legitimate intra-cluster traffic" pattern. Findings:

✅ Automatically fixed by this PR

Site Uses Notes
`a2a_proxy.go:394` `isSafeURL` The callsite that was silently 502-ing every A2A
`mcp_tools.go:161` `isSafeURL` MCP agent-URL resolution
`mcp_tools.go:239` `isSafeURL` MCP second resolution path

All three lift together once `isSafeURL` honours saasMode.

✅ Already correct (saasMode-aware)

Site Uses Why fine
`registry.go validateAgentURL` has its own `if !saasMode()` gate at line 132 The sibling impl that was done right
`mcp_tools.go:477, 487, 509` `validateAgentURL` Inherits registry.go's correct gating
`workspace_provision.go:356` `saasMode()` directly Skips local-only auth-token injection in SaaS

⚠️ Different design, not a defect (but worth noting)

Site Behaviour
`transcript.go validateWorkspaceURL` Intentionally permissive: allows loopback + Docker-internal hostnames; blocks only IMDS endpoints + link-local. Comment: "the whole threat model assumes the platform already trusts peers on that network."
Channel webhooks (`slack.go`, `lark.go`, `discord.go`, `channels_test.go`) URL validated at config-save time against a domain allowlist (`hooks.slack.com`, `hooks.lark.com` etc.) — no per-request SSRF needed because URL shape is constrained earlier
`traces.go` URL built from `LANGFUSE_HOST` env var (server-controlled)
`github_token.go` Hardcoded `https://api.github.com\`

No other sites found where user-attacker-writable URLs flow into `http.Do` without a saasMode-aware validator.

Process / design findings (separate issues worth filing)

A. Test coverage gap — the existing test passed while prod was broken

`TestIsPrivateOrMetadataIP_SaaSMode` in `ssrf_test.go` covers the inner helper `isPrivateOrMetadataIP` but NOT the public wrapper `isSafeURL`. The wrapper never called `saasMode()`, yet the inner helper's test kept passing. This is the canonical shape of "tests assert the intent but not the integration."

Fix: add `TestIsSafeURL_SaaSMode_AllowsRFC1918` that asserts `isSafeURL("http://10.1.2.3/agent\")\` returns nil when `MOLECULE_DEPLOY_MODE=saas`. I'll file this as a follow-up issue.

B. Two parallel SSRF implementations

`isSafeURL` (ssrf.go) and `validateAgentURL` (registry.go) are parallel implementations with subtly different semantics. Consolidation: one source of truth, called from both A2A proxy and registry paths. The current split is why the saasMode bug only hit one of the two.

C. Feature-branch drift (platform bugs getting bundled with feature work)

Commit 62a2118 (this fix) landed on `feat/credential-helper-auto-refresh` on Apr 22. The branch has other unrelated changes (Dockerfile tweaks, script additions) so it hasn't merged. Meanwhile every agent fleet cycle on stale staging/main has been hitting the bug silently.

Class pattern: platform-critical bug fixes get stuck behind larger feature PRs. Suggested process rule: any bug fix with a runtime-error reproducer gets split into a standalone PR to staging within 24h of discovery, even if a larger branch is in flight.

D. Local dev UX — first-run footgun

`docker compose up -d` on main without `MOLECULE_DEPLOY_MODE=saas` set → A2A is instantly broken with a 502 that doesn't surface in compose logs (only in scheduler cron_run errors, only queryable via postgres). Dev-onboarding sharp edge.

Fix options:

  1. Default `MOLECULE_DEPLOY_MODE: ${MOLECULE_DEPLOY_MODE:-saas}` in docker-compose.yml — local dev runs on Docker bridge = RFC-1918 = needs saas mode. Matches reality for all compose users.
  2. Auto-detect: if `/.dockerenv` exists AND workspaces register with 172.x/192.x IPs, log a WARN that suggests enabling saas mode.
  3. Document in `docs/local-dev-setup.md`: add MOLECULE_DEPLOY_MODE=saas as a required env.

I lean (1) + (3). Will file separate issue if this PR is accepted.

Test plan for reviewers

  • Local: `docker compose up -d` with `MOLECULE_DEPLOY_MODE=saas`; curl A2A returns agent JSON
  • Local: `docker compose up -d` WITHOUT the env var; curl A2A returns 502 (confirms the gate still works both ways)
  • SaaS prod: no observable change (was already using this path via feat branch)
  • Self-hosted single-container (`MOLECULE_DEPLOY_MODE=self-hosted`): RFC-1918 still rejected for external attacks

@HongmingWang-Rabbit

Copy link
Copy Markdown
Contributor Author

[molecule-platform-evolvement-manager-agent]

CI Platform(Go) fails with:

internal/handlers/terminal.go:90:57: syntax error: unexpected { at end of statement

This is unrelated to this PR's diff — same pre-existing staging rot from the bot 1,388-commit merge (bot commit 66ea0b64) that's tracked in PR #1769 + issue #1770. This PR touches only ssrf.go; it cannot introduce a terminal.go error.

Once #1769 lands and unblocks staging's Go build, this PR's CI will turn green automatically without any new commits here.

@HongmingWang-Rabbit

Copy link
Copy Markdown
Contributor Author

[molecule-platform-evolvement-manager-agent] Superseded by #1783 (main→staging promotion, folded in the same ssrf.go saasMode fix from commit 62a2118).

molecule-ai Bot pushed a commit that referenced this pull request Apr 23, 2026
…eAgentURL

Issue #1786: SSRF test gap — inner helpers (isPrivateOrMetadataIP,
validateAgentURL blockedRanges) were tested in isolation but the public
wrappers never called saasMode(), allowing the regression to pass unit
tests while production returned 502 on every A2A call from Docker/VPC
deployments (PR #1785).

Adds integration-level wrapper tests for both functions across all
saasMode() resolution ladder cases:
- SaaS explicit (MOLECULE_DEPLOY_MODE=saas): RFC-1918 + fd00 ULA allowed
- Strict mode (MOLECULE_DEPLOY_MODE=self-hosted): RFC-1918 blocked
- Legacy org-ID fallback (MOLECULE_ORG_ID set, no DEPLOY_MODE):
  RFC-1918 + fd00 ULA allowed
- Always-blocked ranges (metadata, loopback, TEST-NET, CGNAT, fc00 ULA)
  stay blocked in every mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
molecule-ai Bot pushed a commit that referenced this pull request Apr 24, 2026
…eAgentURL

Issue #1786: SSRF test gap — inner helpers (isPrivateOrMetadataIP,
validateAgentURL blockedRanges) were tested in isolation but the public
wrappers never called saasMode(), allowing the regression to pass unit
tests while production returned 502 on every A2A call from Docker/VPC
deployments (PR #1785).

Adds integration-level wrapper tests for both functions across all
saasMode() resolution ladder cases:
- SaaS explicit (MOLECULE_DEPLOY_MODE=saas): RFC-1918 + fd00 ULA allowed
- Strict mode (MOLECULE_DEPLOY_MODE=self-hosted): RFC-1918 blocked
- Legacy org-ID fallback (MOLECULE_ORG_ID set, no DEPLOY_MODE):
  RFC-1918 + fd00 ULA allowed
- Always-blocked ranges (metadata, loopback, TEST-NET, CGNAT, fc00 ULA)
  stay blocked in every mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
molecule-ai Bot pushed a commit that referenced this pull request Apr 24, 2026
…eAgentURL

Issue #1786: SSRF test gap — inner helpers (isPrivateOrMetadataIP,
validateAgentURL blockedRanges) were tested in isolation but the public
wrappers never called saasMode(), allowing the regression to pass unit
tests while production returned 502 on every A2A call from Docker/VPC
deployments (PR #1785).

Adds integration-level wrapper tests for both functions across all
saasMode() resolution ladder cases:
- SaaS explicit (MOLECULE_DEPLOY_MODE=saas): RFC-1918 + fd00 ULA allowed
- Strict mode (MOLECULE_DEPLOY_MODE=self-hosted): RFC-1918 blocked
- Legacy org-ID fallback (MOLECULE_ORG_ID set, no DEPLOY_MODE):
  RFC-1918 + fd00 ULA allowed
- Always-blocked ranges (metadata, loopback, TEST-NET, CGNAT, fc00 ULA)
  stay blocked in every mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@molecule-ai
molecule-ai Bot deleted the fix/ssrf-saas-mode-for-private-ips branch May 20, 2026 06:22
HongmingWang-Rabbit pushed a commit that referenced this pull request Jun 12, 2026
chore: retire unmaintained workspace runtimes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant